Skip to content

[SPARK-48290][CORE] Record skewed shuffle block sizes by default so that AQE can detect skew - #57528

Open
Vivek1106-04 wants to merge 2 commits into
apache:masterfrom
Vivek1106-04:SPARK-48290-skew-mapstatus
Open

[SPARK-48290][CORE] Record skewed shuffle block sizes by default so that AQE can detect skew#57528
Vivek1106-04 wants to merge 2 commits into
apache:masterfrom
Vivek1106-04:SPARK-48290-skew-mapstatus

Conversation

@Vivek1106-04

@Vivek1106-04 Vivek1106-04 commented Jul 25, 2026

Copy link
Copy Markdown

What changes were proposed in this pull request?

Once a shuffle has more than spark.shuffle.minNumPartitionsToHighlyCompress (2000) partitions, map statuses switch to HighlyCompressedMapStatus, which keeps exact sizes only for blocks above spark.shuffle.accurateBlockThreshold (100MB) and reports the average block size for everything else. MapOutputTracker.getStatistics sums those per reduce id, so AQE ends up looking at a flat distribution.

spark.shuffle.accurateBlockSkewedFactor, added by SPARK-36967, keeps skewed block sizes accurate, but it ships disabled (-1.0) even though its own documentation recommends setting it to the same value as spark.sql.adaptive.skewJoin.skewedPartitionFactor. Enabling it fully sorted the block sizes in every map task, which is presumably why it was left off.

This PR:

  1. replaces the sort in HighlyCompressedMapStatus.apply with selection of the only two order statistics it needs (the median, and the K-th largest size), using a new in-place Utils.nthSmallest plus Utils.medianInPlace. This is O(numPartitions) on average rather than O(numPartitions log numPartitions), with the same result. nthSmallest is an introselect: the pivot is the median of the first, middle and last element, and it sorts the range still under consideration once the ranges have stopped shrinking quickly enough, which bounds the worst case at O(numPartitions log numPartitions) even for adversarial inputs such as an organ pipe distribution;
  2. enforces spark.shuffle.maxAccurateSkewedBlockNumber as an actual cardinality bound. Previously only the size of the cutoff block bounded the recording, so blocks tied at that size were all recorded. The blocks strictly larger than the cutoff are now counted up front, and the remainder of the budget admits tied blocks in block index order, which keeps the map status deterministic. Blocks at or above spark.shuffle.accurateBlockThreshold are still always recorded, as they were before;
  3. changes the default of spark.shuffle.accurateBlockSkewedFactor from -1.0 to 5.0, matching spark.sql.adaptive.skewJoin.skewedPartitionFactor. -1.0 still disables the feature;
  4. updates the config documentation to say what leaving it off actually costs.

Why are the changes needed?

Skew join optimization silently stops working above 2000 shuffle partitions. In the reporter's case a 263GB partition spread over 10335 mappers is only ~25MB per block, far below the 100MB accurate threshold, so every block is reported as the average and AQE logs median size == max size == min size and finds no skew. Lowering spark.shuffle.accurateBlockThreshold does not help, because what matters is the per-block size rather than the per-partition total.

Reproduced on master with a join where 95% of the rows land on one key, varying only spark.sql.shuffle.partitions:

spark.sql.shuffle.partitions extra configuration skew detected
1999 (defaults) yes, SortMergeJoin(skew=true)
2001 (defaults) no
4000 (defaults) no
2001 spark.shuffle.accurateBlockSkewedFactor=5.0 yes
2001 spark.shuffle.minNumPartitionsToHighlyCompress=100000 yes

Either knob restores detection on its own, which locates the problem in the map status compression rather than in AQE.

The cost of the change, measured with the benchmark added here (per map task, Apple M4, JDK 21):

shuffle partitions skewed sizes not recorded recorded, skewed distribution recorded, organ pipe distribution
2048 6.4us 17.3us 23.6us
10000 14.7us 56.3us 99.5us
50000 54.6us 309.8us 530.2us

For comparison, the sorting implementation this PR replaces measured 28.8us, 140.7us and 1562.0us on the same machine, so at 50000 partitions the accurate path is now about 5x cheaper than sorting would have been. The organ pipe column is the worst case for the selection, where the introselect fallback kicks in; it stays within 1.7x of the ordinary case and does not grow quadratically.

On the driver side each map status carries at most spark.shuffle.maxAccurateSkewedBlockNumber (100) extra entries of an int and a byte, so about 500 bytes per map task, or ~5MB for a 10000 map task stage.

Does this PR introduce any user-facing change?

Yes. Shuffles with more than spark.shuffle.minNumPartitionsToHighlyCompress partitions now report skewed block sizes accurately by default, so AQE can detect and split skewed partitions where it previously could not. Queries affected by this will change plans (skew joins get split) and should run faster. Setting spark.shuffle.accurateBlockSkewedFactor=-1.0 restores the old behavior.

How was this patch tested?

New tests in MapStatusSuite:

  • a skewed block below spark.shuffle.accurateBlockThreshold is recorded accurately under default configuration, and the average is not inflated by it. Fails on master with 88733 did not equal 1500.
  • at 50000 blocks, 25001 of 1 KiB and 24999 of 8 KiB, exactly spark.shuffle.maxAccurateSkewedBlockNumber blocks are recorded and the rest report the average. Fails without the cardinality bound with 1024 did not equal 4600, because all 24999 tied blocks were recorded and the average collapsed to the size of the small blocks.

New tests in UtilsSuite:

  • Utils.nthSmallest agrees with a full sort over sorted, reverse sorted, constant, organ pipe, duplicate-heavy and random inputs, and its argument validation.
  • Utils.nthSmallest leaves the array partitioned around the returned element. HighlyCompressedMapStatus counts the sizes above the cutoff by scanning only the tail of the array, so it depends on this and not just on the returned value.
  • six selections over a 1048576 element organ pipe complete within a 60 second limit. A quadratic selection needs more than 10^11 comparisons there; the introselect takes 100ms in total.
  • Utils.medianInPlace agrees with Utils.median.

HighlyCompressedMapStatusBenchmark is new, with results generated at 2048, 10000 and 50000 partitions for both distributions.

Existing suites: MapStatusSuite, MapOutputTrackerSuite, ShuffleSuite, UtilsSuite, org.apache.spark.shuffle.sort.* and AdaptiveQueryExecSuite (129 tests) all pass.

End to end, with a locally built assembly and no configuration beyond the SQL level skew join settings, the table above becomes yes / yes / yes at 1999, 2001 and 4000 partitions.

I did not add an AQE level test for the >2000 partition case: spark.shuffle.minNumPartitionsToHighlyCompress is read through a lazy val on the JVM's SparkEnv, so it cannot be lowered from within a suite, and running a query with 2001 shuffle partitions takes about a minute. SPARK-36967 tested this feature at MapStatusSuite level for the same reason. Happy to add one if reviewers would rather pay the runtime.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code

…hat AQE can detect skew

Once a shuffle has more than spark.shuffle.minNumPartitionsToHighlyCompress
(2000) partitions, map statuses switch to HighlyCompressedMapStatus, which
reports every block below spark.shuffle.accurateBlockThreshold as the average
block size. A skewed partition whose rows are spread over many map tasks has no
individual block above that threshold, so its size is averaged away, adaptive
query execution sees min == median == max and the skew is left unhandled.

spark.shuffle.accurateBlockSkewedFactor, added by SPARK-36967, keeps the sizes
of skewed blocks accurate, but it shipped disabled because enabling it sorted
the block sizes in every map task. Only two order statistics are needed, so
they are now selected in O(numPartitions) instead of sorting, and the feature
is enabled by default with the value its documentation already recommended.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two default-enabled P1 regressions are reproducible at the current head: cutoff ties bypass the configured skewed-block cap, and midpoint-pivot selection becomes quadratic on valid partition-size distributions. Please see the inline comments for concrete reproductions and suggested fixes.

val sizes = uncompressedSizes.clone()
val medianSize: Long = Utils.medianInPlace(sizes)
val smallestAccurateSize =
Utils.nthSmallest(sizes, totalNumBlocks - maxAccurateSkewedBlockNumber)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Enforce the skewed-block cap when the cutoff has ties

nthSmallest returns the 100th-largest block size, but the classification below records every block with size >= threshold. This means ties at the cutoff bypass spark.shuffle.maxAccurateSkewedBlockNumber.

For example, with the proposed defaults and 50,000 reduce partitions, consider 25,001 blocks of 1 KiB and 24,999 blocks of 8 KiB. The median is 1 KiB, the 100th-largest size is 8 KiB, and the threshold is 8 KiB, so all 24,999 larger blocks are recorded instead of at most 100. writeExternal writes at least five bytes per recorded entry, so a 10,000-map stage produces 1,249,950,000 bytes of uncompressed map-status entries, before the much larger driver-side collection overhead. The base branch's disabled default does not record any of these blocks.

Since this PR enables this path by default, please enforce the actual cardinality bound, including deterministic handling of ties, while preserving mandatory accurate recording above spark.shuffle.accurateBlockThreshold. Please also add a regression test containing more than 100 equal-sized blocks at the cutoff.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and the reproduction is exact. Fixed in 2ced43b.

The cutoff size alone was never a cardinality bound, only a size bound. The blocks strictly larger than the cutoff are now counted up front -- the selection leaves them all in the top-K window, so that count is at most the configured maximum -- and the remainder of the budget admits blocks of exactly the cutoff size, in block index order, so the map status stays deterministic. No strictly larger block is ever dropped in favour of a tied one, since only ties are rationed. Blocks at or above spark.shuffle.accurateBlockThreshold are still recorded unconditionally, so the mandatory path is unchanged.

The regression test is your example verbatim: 50000 partitions, 25001 blocks of 1 KiB and 24999 of 8 KiB, stock defaults. Without the bound it fails with 1024 did not equal 4600 -- all 24999 tied blocks get recorded and avgSize collapses to the size of the small blocks, which is a neat second symptom of the same bug.

while (low < high) {
// The middle element keeps already sorted and reverse sorted inputs, which are both common
// for shuffle block sizes, away from the quadratic worst case.
val pivot = sizes(low + (high - low) / 2)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Bound the worst-case cost of default-enabled quickselect

Always selecting the middle element makes the algorithm quadratic for a valid organ-pipe reducer-size distribution: Array.tabulate(n)(i => math.min(i, n - 1 - i).toLong). Replaying the exact selections made by HighlyCompressedMapStatus produces 805,003 comparisons at 2,048 partitions, 3,174,774 at 4,096, 12,682,753 at 8,192, and 50,455,932 at 16,384. Doubling the number of partitions approximately quadruples the work.

This becomes a production regression because the PR changes spark.shuffle.accurateBlockSkewedFactor from -1.0 to 5.0, making every map task in a qualifying shuffle execute these selections; the base default performs no selection. At 16,384 partitions, a 10,000-map stage would require more than 504 billion comparisons just to construct map statuses.

Please use a worst-case-bounded selection algorithm, or an introspective/randomized pivot with a safe fallback, and add an organ-pipe input to the tests and benchmark.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and thanks for the organ pipe input specifically. Fixed in 2ced43b.

nthSmallest is now an introselect: median-of-three pivot, and it sorts the range still under consideration once the ranges have stopped shrinking for 2 * log2(n) rounds. That bounds the worst case at O(n log n) regardless of the input, rather than relying on the pivot choice being lucky.

One thing I want to be upfront about: I did not reproduce your comparison counts or instrument the post-fix ones. What I added is a time bound on the input you named -- six selections over a 2^20 organ pipe under an explicit 60 second limit, which currently run in 100ms total. Extrapolating your table (50,455,932 at 16384, roughly quadrupling per doubling) puts a quadratic selection at more than 10^11 comparisons there, so the limit is generous enough not to be flaky while still failing decisively on a regression. If you would rather have an actual comparison count asserted against a ceiling, I can add one, though it means an instrumented copy of the loop in test code.

I also added a test asserting that nthSmallest leaves the array partitioned around the returned element, since HighlyCompressedMapStatus now counts the sizes above the cutoff by scanning only the tail of the array and so depends on that ordering, not just on the returned value.

The benchmark now reports the organ pipe distribution alongside the skewed one. At 50000 partitions it is 530us per map task against 310us for the ordinary case -- a 1.7x gap, not a blowup.

…lection cost

Address two problems with enabling the accurate recording of skewed block
sizes by default.

The number of accurately recorded blocks was only bounded by the size of the
cutoff block, so blocks tied at that size were all recorded and could exceed
spark.shuffle.maxAccurateSkewedBlockNumber by orders of magnitude. With 50000
reduce partitions, 25001 blocks of 1 KiB and 24999 blocks of 8 KiB, all 24999
larger blocks were recorded instead of at most 100. The blocks strictly larger
than the cutoff are now counted up front, and the rest of the budget admits the
tied blocks in block index order, which keeps the map status deterministic.
Blocks at or above spark.shuffle.accurateBlockThreshold are still always
recorded, as they were before.

Utils.nthSmallest picked the middle element as the pivot, which is quadratic
for an organ pipe distribution of block sizes. It is now an introselect: the
pivot is the median of the first, middle and last element, and the range still
under consideration is sorted once the ranges have stopped shrinking quickly
enough, which bounds the worst case at O(numPartitions * log(numPartitions)).

Both cases are covered by new tests, the ordering that the block count relies
on is now asserted directly, and the benchmark also reports the organ pipe
distribution.
@Vivek1106-04
Vivek1106-04 requested a review from sunchao July 27, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants